diff --git a/src/components/forms/custom-form-builder.tsx b/src/components/forms/custom-form-builder.tsx index b135bb9..053efbc 100644 --- a/src/components/forms/custom-form-builder.tsx +++ b/src/components/forms/custom-form-builder.tsx @@ -520,6 +520,65 @@ export function CustomFormBuilder() { const LIST_INDENT = 24; // px per nesting level + const toRoman = (num: number) => { + const map: Array<[number, string]> = [ + [1000, "M"], + [900, "CM"], + [500, "D"], + [400, "CD"], + [100, "C"], + [90, "XC"], + [50, "L"], + [40, "XL"], + [10, "X"], + [9, "IX"], + [5, "V"], + [4, "IV"], + [1, "I"], + ]; + let out = ""; + let n = Math.max(1, Math.floor(num)); + for (const [value, glyph] of map) { + while (n >= value) { + out += glyph; + n -= value; + } + } + return out; + }; + + const formatOrderedMarker = (index: number, listEl: HTMLElement) => { + const explicitType = (listEl.getAttribute("type") || "").toLowerCase(); + const styleType = (listEl.style.listStyleType || "").toLowerCase(); + const kind = explicitType || styleType; + if (kind === "a" || kind === "lower-alpha") return `${String.fromCharCode(96 + Math.max(1, index))}.`; + if (kind === "A" || kind === "upper-alpha") return `${String.fromCharCode(64 + Math.max(1, index))}.`; + if (kind === "i" || kind === "lower-roman") return `${toRoman(index).toLowerCase()}.`; + if (kind === "I" || kind === "upper-roman") return `${toRoman(index)}.`; + return `${index}.`; + }; + + const extractLeadingManualMarker = (runs: InlineRun[]) => { + const preview = runs.map((r) => r.text).join("").slice(0, 32); + const match = preview.match(/^\s*((?:\d+|[A-Za-z]+|[ivxlcdmIVXLCDM]+)[.)])\s+/); + if (!match) return { marker: null as string | null, runs }; + let remaining = match[0].length; + const trimmed: InlineRun[] = []; + for (const run of runs) { + if (remaining <= 0) { + trimmed.push(run); + continue; + } + if (run.text.length <= remaining) { + remaining -= run.text.length; + continue; + } + trimmed.push({ ...run, text: run.text.slice(remaining) }); + remaining = 0; + } + return { marker: match[1], runs: mergeRuns(trimmed) }; + }; + const pushListItems = ( listEl: HTMLElement, ordered: boolean, @@ -528,26 +587,26 @@ export function CustomFormBuilder() { const items = Array.from(listEl.children).filter( (c) => (c as HTMLElement).tagName.toLowerCase() === "li", ) as HTMLElement[]; - let n = 1; + const startAttr = parseInt(listEl.getAttribute("start") || "1", 10); + let n = Number.isFinite(startAttr) && startAttr > 0 ? startAttr : 1; for (const li of items) { - // Collect inline runs from this li, excluding nested lists. const inlineNodes = Array.from(li.childNodes).filter((c) => { if (c.nodeType !== Node.ELEMENT_NODE) return true; const t = (c as HTMLElement).tagName.toLowerCase(); return t !== "ol" && t !== "ul"; }); const runs: InlineRun[] = []; - inlineNodes.forEach((n) => runs.push(...collectRuns(n))); + inlineNodes.forEach((node) => runs.push(...collectRuns(node))); const merged = mergeRuns(runs); + const manual = ordered ? extractLeadingManualMarker(merged) : { marker: null as string | null, runs: merged }; out.push({ kind: "text", - runs: merged, + runs: manual.runs, align: "left", indent: depth * LIST_INDENT, - marker: ordered ? `${n}.` : "•", + marker: ordered ? manual.marker ?? formatOrderedMarker(n, listEl) : "•", }); n += 1; - // Recurse into any nested lists. Array.from(li.children).forEach((child) => { const tag = (child as HTMLElement).tagName.toLowerCase(); if (tag === "ol") pushListItems(child as HTMLElement, true, depth + 1); @@ -801,21 +860,28 @@ export function CustomFormBuilder() { } const indentPx = seg.indent || 0; const indentPt = indentPx * 0.75; // px → pt - const segMaxW = maxW - indentPt; - const startX = margin + indentPt; - const runsForLayout: InlineRun[] = seg.marker - ? [{ text: `${seg.marker} ` }, ...seg.runs] - : seg.runs; - const visualLines = runsToLines(runsForLayout); + const markerGapPt = seg.marker ? Math.max(14, doc.getTextWidth(seg.marker) + 8) : 0; + const markerX = margin + indentPt; + const textX = markerX + markerGapPt; + const segMaxW = Math.max(1, maxW - indentPt - markerGapPt); + const visualLines = runsToLines(seg.runs); if (visualLines.length === 0 || (visualLines.length === 1 && visualLines[0].length === 0)) { y += lineH; } else { + if (seg.marker) { + if (y > pageH - margin) { + doc.addPage(); + y = margin; + } + doc.setFont(pdfFont, "normal"); + doc.text(seg.marker, markerX, y); + } for (const lineRuns of visualLines) { if (lineRuns.length === 0) { y += lineH; continue; } - y = drawRunsLine(lineRuns, startX, y, segMaxW, seg.align); + y = drawRunsLine(lineRuns, textX, y, segMaxW, seg.align); } } y += seg.caption ? 2 : 4; @@ -958,7 +1024,7 @@ export function CustomFormBuilder() { new DocxParagraph({ alignment: align, spacing: docxLineSpacing, - indent: indentTwips ? { left: indentTwips, hanging: seg.marker ? 360 : undefined } : seg.marker ? { left: 360, hanging: 360 } : undefined, + indent: seg.marker ? { left: indentTwips + 360, hanging: 360 } : indentTwips ? { left: indentTwips } : undefined, children: runsWithMarker, }), ); diff --git a/src/styles.css b/src/styles.css index 9da6131..7d6fc8d 100644 --- a/src/styles.css +++ b/src/styles.css @@ -144,8 +144,42 @@ h1, h2, h3, h4 { font-family: var(--font-serif); letter-spacing: -0.01em; } } -/* Custom form editor: table + caption styling */ +/* Custom form editor: table + list + caption styling */ .ProseMirror table { border-collapse: collapse; margin: 8px 0; width: 100%; table-layout: fixed; } .ProseMirror th, .ProseMirror td { border: 1px solid #999; padding: 6px 8px; vertical-align: top; min-width: 40px; } .ProseMirror th { background: #f3f4f6; font-weight: 600; text-align: left; } .ProseMirror .selectedCell { background: rgba(59,130,246,0.15); } +.ProseMirror ul, +.ProseMirror ol { + margin: 0.5rem 0; + list-style-position: outside; +} +.ProseMirror ul { + list-style-type: disc !important; + padding-inline-start: 1.5rem; +} +.ProseMirror ul ul { + list-style-type: circle !important; +} +.ProseMirror ul ul ul { + list-style-type: square !important; +} +.ProseMirror ol { + list-style-type: decimal !important; + padding-inline-start: 1.75rem; +} +.ProseMirror ol ol { + list-style-type: lower-alpha !important; +} +.ProseMirror ol ol ol { + list-style-type: lower-roman !important; +} +.ProseMirror li { + display: list-item; +} +.ProseMirror li > p { + margin: 0; +} +.ProseMirror li::marker { + color: var(--color-foreground); +}