Enhance nested loop functionality in Interview Wizard Modal
Some checks are pending
Node.js build / build (20.x) (push) Waiting to run
Node.js build / build (22.x) (push) Waiting to run

- Added support for tracking active loop paths, enabling nested loop navigation.
- Implemented breadcrumb navigation to display loop hierarchy and allow users to navigate back to parent loops.
- Updated rendering logic for nested loops to support fullscreen mode and improved item management with edit, delete, and navigation options.
- Enhanced user experience with context headers showing parent loop item details and improved item editing capabilities.
This commit is contained in:
Lars 2026-01-16 20:53:02 +01:00
parent 5a171987b2
commit c3357a784b
2 changed files with 538 additions and 233 deletions

View File

@ -9,6 +9,7 @@ export interface WizardState {
loopContexts: Map<string, unknown[]>; // loop key -> array of collected items (deprecated, use loopRuntimeStates)
loopRuntimeStates: Map<string, LoopRuntimeState>; // loop step key -> runtime state
patches: Patch[]; // Collected patches to apply
activeLoopPath: string[]; // Stack of loop keys representing current nesting level (e.g. ["items", "item_list"])
}
export interface Patch {
@ -36,6 +37,7 @@ export function createWizardState(profile: InterviewProfile): WizardState {
loopContexts: new Map(), // Keep for backwards compatibility
loopRuntimeStates: new Map(),
patches: [],
activeLoopPath: [], // Start at top level
};
}

View File

@ -9,7 +9,7 @@ import {
TextAreaComponent,
TextComponent,
} from "obsidian";
import type { InterviewProfile, InterviewStep } from "../interview/types";
import type { InterviewProfile, InterviewStep, LoopStep } from "../interview/types";
import {
type WizardState,
createWizardState,
@ -596,6 +596,24 @@ export class InterviewWizardModal extends Modal {
renderLoopStep(step: InterviewStep, containerEl: HTMLElement): void {
if (step.type !== "loop") return;
// Check if we're in a nested loop (fullscreen mode)
const currentLoopKey = this.state.activeLoopPath.length > 0
? this.state.activeLoopPath[this.state.activeLoopPath.length - 1]
: null;
// If we're in a nested loop, find which nested step it is
if (currentLoopKey && currentLoopKey.startsWith(step.key + ".")) {
// We're in a nested loop of this step - render it in fullscreen mode
this.renderNestedLoopFullscreen(step, containerEl, currentLoopKey);
return;
}
// Otherwise, render normal loop (or top-level loop)
if (this.state.activeLoopPath.length > 0 && this.state.activeLoopPath[0] !== step.key) {
// We're in a different loop's nested loop, don't render this one
return;
}
// Initialize or get loop runtime state
let loopState = this.state.loopRuntimeStates.get(step.key);
if (!loopState) {
@ -619,8 +637,14 @@ export class InterviewWizardModal extends Modal {
nestedStepsCount: step.items.length,
editIndex: loopState.editIndex,
commitMode: commitMode,
activeLoopPath: this.state.activeLoopPath,
});
// Breadcrumb navigation if in nested context
if (this.state.activeLoopPath.length > 0) {
this.renderBreadcrumb(containerEl, step);
}
// Title
containerEl.createEl("h2", {
text: step.label || "Loop",
@ -954,6 +978,9 @@ export class InterviewWizardModal extends Modal {
onFieldChange: (fieldId: string, value: unknown) => void
): void {
const existingValue = draftValue !== undefined ? String(draftValue) : "";
// Use unique key for preview mode tracking in nested loops
const previewKey = `${loopKey}.${nestedStep.key}`;
const isPreviewMode = this.previewMode.get(previewKey) || false;
if (nestedStep.type === "capture_text") {
// Field container
@ -977,16 +1004,31 @@ export class InterviewWizardModal extends Modal {
});
}
// Editor container
// Container for editor/preview
const editorContainer = fieldContainer.createEl("div", {
cls: "markdown-editor-container",
});
editorContainer.style.width = "100%";
editorContainer.style.position = "relative";
// Preview container (hidden by default)
const previewContainer = editorContainer.createEl("div", {
cls: "markdown-preview-container",
});
previewContainer.style.display = isPreviewMode ? "block" : "none";
previewContainer.style.width = "100%";
previewContainer.style.minHeight = "240px";
previewContainer.style.padding = "1em";
previewContainer.style.border = "1px solid var(--background-modifier-border)";
previewContainer.style.borderRadius = "4px";
previewContainer.style.background = "var(--background-primary)";
previewContainer.style.overflowY = "auto";
// Editor container
const textEditorContainer = editorContainer.createEl("div", {
cls: "markdown-editor-wrapper",
});
textEditorContainer.style.display = isPreviewMode ? "none" : "block";
textEditorContainer.style.width = "100%";
const textSetting = new Setting(textEditorContainer);
@ -999,10 +1041,18 @@ export class InterviewWizardModal extends Modal {
settingNameEl.style.display = "none";
}
let textareaRef: HTMLTextAreaElement | null = null;
textSetting.addTextArea((text) => {
textareaRef = text.inputEl;
text.setValue(existingValue);
text.onChange((value) => {
onFieldChange(nestedStep.key, value);
// Update preview if in preview mode
const currentPreviewMode = this.previewMode.get(previewKey) || false;
if (currentPreviewMode) {
this.updatePreview(previewContainer, value);
}
});
text.inputEl.rows = 8;
text.inputEl.style.width = "100%";
@ -1010,14 +1060,34 @@ export class InterviewWizardModal extends Modal {
text.inputEl.style.boxSizing = "border-box";
});
// Add toolbar
// Add toolbar with preview toggle
setTimeout(() => {
const textarea = textEditorContainer.querySelector("textarea");
if (textarea) {
const itemToolbar = createMarkdownToolbar(textarea);
const itemToolbar = createMarkdownToolbar(
textarea,
() => {
// Get current value from textarea before toggling
const currentValue = textarea.value;
// Update draft with current value
onFieldChange(nestedStep.key, currentValue);
// Toggle preview mode
const newPreviewMode = !this.previewMode.get(previewKey);
this.previewMode.set(previewKey, newPreviewMode);
// Re-render to show/hide preview
this.renderStep();
}
);
textEditorContainer.insertBefore(itemToolbar, textEditorContainer.firstChild);
}
}, 10);
// Render preview if in preview mode
if (isPreviewMode && existingValue) {
this.updatePreview(previewContainer, existingValue);
}
} else if (nestedStep.type === "capture_text_line") {
// Field container
const fieldContainer = containerEl.createEl("div", {
@ -1112,12 +1182,11 @@ export class InterviewWizardModal extends Modal {
text.inputEl.style.boxSizing = "border-box";
});
} else if (nestedStep.type === "loop") {
// Nested loop: render as a nested loop editor
// The draft value should be an array of items
// Nested loop: render as a button to enter fullscreen mode
const nestedLoopItems = Array.isArray(draftValue) ? draftValue : [];
const nestedLoopKey = `${loopKey}.${nestedStep.key}`;
// Get or create nested loop state
const nestedLoopKey = `${loopKey}.${nestedStep.key}`;
let nestedLoopState = this.state.loopRuntimeStates.get(nestedLoopKey);
if (!nestedLoopState) {
nestedLoopState = {
@ -1129,276 +1198,510 @@ export class InterviewWizardModal extends Modal {
this.state.loopRuntimeStates.set(nestedLoopKey, nestedLoopState);
}
// Render nested loop UI - use a simplified 2-pane layout
const nestedLoopContainer = containerEl.createEl("div", {
cls: "nested-loop-container",
// Field container
const fieldContainer = containerEl.createEl("div", {
cls: "mindnet-field",
});
nestedLoopContainer.style.border = "1px solid var(--background-modifier-border)";
nestedLoopContainer.style.borderRadius = "4px";
nestedLoopContainer.style.padding = "1em";
nestedLoopContainer.style.marginTop = "0.5em";
// Label
if (nestedStep.label) {
const labelEl = nestedLoopContainer.createEl("div", {
const labelEl = fieldContainer.createEl("div", {
cls: "mindnet-field__label",
text: nestedStep.label,
});
labelEl.style.marginBottom = "0.5em";
labelEl.style.fontWeight = "bold";
}
// Create 2-pane layout for nested loop
const nestedPaneContainer = nestedLoopContainer.createEl("div", {
cls: "nested-loop-panes",
// Show item count
const countText = nestedLoopState.items.length > 0
? `${nestedLoopState.items.length} ${nestedLoopState.items.length === 1 ? "Eintrag" : "Einträge"}`
: "Keine Einträge";
const countEl = fieldContainer.createEl("div", {
cls: "mindnet-field__desc",
text: countText,
});
nestedPaneContainer.style.display = "flex";
nestedPaneContainer.style.gap = "1em";
nestedPaneContainer.style.minHeight = "200px";
countEl.style.marginBottom = "0.5em";
// Left pane: Items list (narrower for nested loops)
const nestedLeftPane = nestedPaneContainer.createEl("div", {
cls: "nested-loop-items-pane",
// Button to enter nested loop (fullscreen mode)
const enterBtn = fieldContainer.createEl("button", {
text: nestedLoopState.items.length > 0 ? "Bearbeiten" : "Hinzufügen",
cls: "mod-cta",
});
nestedLeftPane.style.width = "25%";
nestedLeftPane.style.borderRight = "1px solid var(--background-modifier-border)";
nestedLeftPane.style.paddingRight = "1em";
nestedLeftPane.style.maxHeight = "300px";
nestedLeftPane.style.overflowY = "auto";
enterBtn.style.width = "100%";
enterBtn.onclick = () => {
// Enter nested loop: add to activeLoopPath
this.state.activeLoopPath.push(nestedLoopKey);
this.renderStep();
};
}
}
/**
* Render breadcrumb navigation showing loop hierarchy.
*/
private renderBreadcrumb(containerEl: HTMLElement, currentStep: InterviewStep): void {
const breadcrumbContainer = containerEl.createEl("div", {
cls: "loop-breadcrumb",
});
breadcrumbContainer.style.display = "flex";
breadcrumbContainer.style.alignItems = "center";
breadcrumbContainer.style.gap = "0.5em";
breadcrumbContainer.style.marginBottom = "1em";
breadcrumbContainer.style.padding = "0.5em";
breadcrumbContainer.style.background = "var(--background-secondary)";
breadcrumbContainer.style.borderRadius = "4px";
// Build breadcrumb path
const path: Array<{ key: string; label: string }> = [];
// Find all parent loops
for (let i = 0; i < this.state.activeLoopPath.length; i++) {
const loopKey = this.state.activeLoopPath[i];
if (!loopKey) continue;
const nestedItemsTitle = nestedLeftPane.createEl("div", {
text: "Einträge",
cls: "mindnet-field__label",
// Extract step key from loop key (e.g., "items.item_list" -> "item_list")
const parts = loopKey.split(".");
const stepKey = parts[parts.length - 1];
if (!stepKey) continue;
// Find the step in the profile
const step = this.findStepByKey(stepKey);
if (step && step.type === "loop") {
path.push({ key: loopKey, label: step.label || stepKey });
}
}
// Render breadcrumb
path.forEach((item, index) => {
if (index > 0) {
breadcrumbContainer.createEl("span", { text: "" });
}
const breadcrumbItem = breadcrumbContainer.createEl("button", {
text: item.label,
cls: "breadcrumb-item",
});
nestedItemsTitle.style.marginBottom = "0.5em";
breadcrumbItem.style.background = "transparent";
breadcrumbItem.style.border = "none";
breadcrumbItem.style.cursor = "pointer";
breadcrumbItem.style.textDecoration = index < path.length - 1 ? "underline" : "none";
// Render items list
nestedLoopState.items.forEach((item, i) => {
const itemEl = nestedLeftPane.createEl("div", {
cls: "nested-loop-item",
});
itemEl.style.padding = "0.5em";
itemEl.style.marginBottom = "0.25em";
itemEl.style.background = "var(--background-secondary)";
itemEl.style.borderRadius = "4px";
itemEl.style.cursor = "pointer";
// Extract first non-empty field value for display
let itemText = `Item ${i + 1}`;
if (typeof item === "object" && item !== null) {
const itemObj = item as Record<string, unknown>;
// Try to find first non-empty string value
for (const [key, value] of Object.entries(itemObj)) {
if (value && typeof value === "string" && value.trim() !== "") {
itemText = value.trim();
break;
}
}
} else if (item) {
itemText = String(item);
if (index < path.length - 1) {
breadcrumbItem.onclick = () => {
// Navigate to this level
this.state.activeLoopPath = this.state.activeLoopPath.slice(0, index + 1);
this.renderStep();
};
}
});
// Back button to parent level
if (this.state.activeLoopPath.length > 0) {
const backBtn = breadcrumbContainer.createEl("button", {
text: "← Zurück",
cls: "mod-cta",
});
backBtn.style.marginLeft = "auto";
backBtn.onclick = () => {
this.state.activeLoopPath.pop();
this.renderStep();
};
}
}
/**
* Find a step by its key in the profile (recursive search).
*/
private findStepByKey(key: string): InterviewStep | null {
const searchInSteps = (steps: InterviewStep[]): InterviewStep | null => {
for (const step of steps) {
if (step.key === key) {
return step;
}
itemEl.textContent = itemText.length > 40 ? itemText.substring(0, 40) + "..." : itemText;
// Edit button
const editBtn = itemEl.createEl("button", {
text: "✏️",
cls: "nested-edit-btn",
});
editBtn.style.float = "right";
editBtn.style.marginLeft = "0.5em";
editBtn.onclick = (e) => {
e.stopPropagation();
const currentNestedState = this.state.loopRuntimeStates.get(nestedLoopKey);
if (currentNestedState) {
let newState = startEdit(currentNestedState, i);
newState = resetItemWizard(newState);
this.state.loopRuntimeStates.set(nestedLoopKey, newState);
this.renderStep();
}
};
// Move Up button
const moveUpBtn = itemEl.createEl("button", {
text: "↑",
cls: "nested-move-up-btn",
});
moveUpBtn.style.float = "right";
moveUpBtn.style.marginLeft = "0.25em";
moveUpBtn.disabled = i === 0;
moveUpBtn.onclick = (e) => {
e.stopPropagation();
const currentNestedState = this.state.loopRuntimeStates.get(nestedLoopKey);
if (currentNestedState) {
const newState = moveItemUp(currentNestedState, i);
this.state.loopRuntimeStates.set(nestedLoopKey, newState);
// Update parent draft
onFieldChange(nestedStep.key, newState.items);
this.renderStep();
}
};
// Move Down button
const moveDownBtn = itemEl.createEl("button", {
text: "↓",
cls: "nested-move-down-btn",
});
moveDownBtn.style.float = "right";
moveDownBtn.style.marginLeft = "0.25em";
moveDownBtn.disabled = i >= nestedLoopState.items.length - 1;
moveDownBtn.onclick = (e) => {
e.stopPropagation();
const currentNestedState = this.state.loopRuntimeStates.get(nestedLoopKey);
if (currentNestedState) {
const newState = moveItemDown(currentNestedState, i);
this.state.loopRuntimeStates.set(nestedLoopKey, newState);
// Update parent draft
onFieldChange(nestedStep.key, newState.items);
this.renderStep();
}
};
// Delete button
const deleteBtn = itemEl.createEl("button", {
text: "🗑️",
cls: "nested-delete-btn",
});
deleteBtn.style.float = "right";
deleteBtn.style.marginLeft = "0.25em";
deleteBtn.onclick = (e) => {
e.stopPropagation();
const currentNestedState = this.state.loopRuntimeStates.get(nestedLoopKey);
if (currentNestedState) {
const newState = deleteItem(currentNestedState, i);
this.state.loopRuntimeStates.set(nestedLoopKey, newState);
// Update parent draft
onFieldChange(nestedStep.key, newState.items);
this.renderStep();
}
};
});
if (step.type === "loop") {
const found = searchInSteps(step.items);
if (found) return found;
}
}
return null;
};
return searchInSteps(this.state.profile.steps);
}
/**
* Render a nested loop in fullscreen mode (uses full width).
*/
private renderNestedLoopFullscreen(
parentStep: InterviewStep,
containerEl: HTMLElement,
nestedLoopKey: string
): void {
// Extract the nested step from parent step
const nestedStepKey = nestedLoopKey.split(".").pop();
if (!nestedStepKey) return;
// parentStep must be a LoopStep to have items
if (parentStep.type !== "loop") return;
const loopStep = parentStep as LoopStep;
const nestedStep = loopStep.items.find((s: InterviewStep) => s.key === nestedStepKey);
if (!nestedStep || nestedStep.type !== "loop") return;
// Get nested loop state
let nestedLoopState = this.state.loopRuntimeStates.get(nestedLoopKey);
if (!nestedLoopState) {
nestedLoopState = {
items: [],
draft: {},
editIndex: null,
activeItemStepIndex: 0,
};
this.state.loopRuntimeStates.set(nestedLoopKey, nestedLoopState);
}
// Breadcrumb
this.renderBreadcrumb(containerEl, nestedStep);
// Context header: Show parent loop item context
const contextHeader = containerEl.createEl("div", {
cls: "nested-loop-context",
});
contextHeader.style.padding = "1em";
contextHeader.style.background = "var(--background-secondary)";
contextHeader.style.borderRadius = "6px";
contextHeader.style.marginBottom = "1.5em";
contextHeader.style.border = "1px solid var(--background-modifier-border)";
// Find parent loop context
const lastDotIndex = nestedLoopKey.lastIndexOf(".");
if (lastDotIndex > 0) {
const parentLoopKey = nestedLoopKey.substring(0, lastDotIndex);
const parentLoopState = this.state.loopRuntimeStates.get(parentLoopKey);
// Right pane: Editor for nested loop (wider for nested loops)
const nestedRightPane = nestedPaneContainer.createEl("div", {
cls: "nested-loop-editor-pane",
});
nestedRightPane.style.width = "75%";
nestedRightPane.style.flex = "1";
const nestedEditorTitle = nestedRightPane.createEl("div", {
cls: "mindnet-field__label",
});
const nestedTitleText = nestedLoopState.editIndex !== null
? `Eintrag ${nestedLoopState.editIndex + 1} bearbeiten`
: "Neuer Eintrag";
const nestedStepCounter = nestedStep.items.length > 0
? ` - Schritt ${nestedLoopState.activeItemStepIndex + 1}/${nestedStep.items.length}`
: "";
nestedEditorTitle.textContent = nestedTitleText + nestedStepCounter;
nestedEditorTitle.style.marginBottom = "0.5em";
// Render active nested step
if (nestedStep.items.length > 0) {
const activeNestedStepIndex = Math.min(nestedLoopState.activeItemStepIndex, nestedStep.items.length - 1);
const activeNestedNestedStep = nestedStep.items[activeNestedStepIndex];
if (parentLoopState) {
// Find the top-level loop step
const topLevelLoopKey = nestedLoopKey.split(".")[0];
if (!topLevelLoopKey) {
// Fallback if no top-level key found
const contextTitle = contextHeader.createEl("div", {
cls: "context-title",
text: "📍 Kontext: " + (parentStep.label || "Parent Loop"),
});
contextTitle.style.fontWeight = "bold";
contextTitle.style.marginBottom = "0.5em";
contextTitle.style.fontSize = "0.9em";
contextTitle.style.color = "var(--text-muted)";
} else {
const topLevelLoopState = this.state.loopRuntimeStates.get(topLevelLoopKey);
const topLevelStep = this.findStepByKey(topLevelLoopKey);
// Build context path: top-level loop > nested loop (current)
const contextPath: string[] = [];
if (topLevelStep && topLevelStep.type === "loop") {
contextPath.push(topLevelStep.label || topLevelLoopKey);
}
// Add the nested loop label (the one we're currently in)
if (nestedStep && nestedStep.type === "loop") {
contextPath.push(nestedStep.label || nestedStepKey || "");
}
const contextTitle = contextHeader.createEl("div", {
cls: "context-title",
});
contextTitle.style.fontWeight = "bold";
contextTitle.style.marginBottom = "0.5em";
contextTitle.style.fontSize = "0.9em";
contextTitle.style.color = "var(--text-muted)";
if (contextPath.length > 0) {
contextTitle.textContent = "📍 Kontext: " + contextPath.join(" ");
} else {
contextTitle.textContent = "📍 Kontext: " + (parentStep.label || "Parent Loop");
}
}
// Recursively render nested step (supports arbitrary nesting depth)
if (activeNestedNestedStep) {
const nestedDraftValue = nestedLoopState.draft[activeNestedNestedStep.key];
this.renderLoopNestedStep(
activeNestedNestedStep,
nestedRightPane,
nestedLoopKey,
nestedDraftValue,
(fieldId, value) => {
// Update state without re-rendering to preserve focus
const currentNestedState = this.state.loopRuntimeStates.get(nestedLoopKey);
if (currentNestedState) {
const newState = setDraftField(currentNestedState, fieldId, value);
this.state.loopRuntimeStates.set(nestedLoopKey, newState);
// Update parent draft without re-rendering
const parentLoopState = this.state.loopRuntimeStates.get(loopKey);
if (parentLoopState) {
const updatedParentDraft = {
...parentLoopState.draft,
[nestedStep.key]: newState.items,
};
const updatedParentState = setDraftField(parentLoopState, nestedStep.key, newState.items);
this.state.loopRuntimeStates.set(loopKey, updatedParentState);
}
// Do NOT call renderStep() here - it causes focus loss
// Show which parent item we're editing
if (parentLoopState.editIndex !== null) {
const parentItem = parentLoopState.items[parentLoopState.editIndex];
if (parentItem && typeof parentItem === "object") {
const parentItemObj = parentItem as Record<string, unknown>;
const contextInfo = contextHeader.createEl("div", {
cls: "context-info",
});
contextInfo.style.fontSize = "0.85em";
contextInfo.style.color = "var(--text-normal)";
// Show parent item fields (excluding the nested loop field itself)
const parentFields: string[] = [];
for (const [key, value] of Object.entries(parentItemObj)) {
if (nestedStepKey && key !== nestedStepKey && value && typeof value === "string" && value.trim() !== "") {
const step = loopStep.items.find(s => s.key === key);
const label = step?.label || key;
parentFields.push(`${label}: ${value.trim().substring(0, 60)}${value.trim().length > 60 ? "..." : ""}`);
}
}
);
if (parentFields.length > 0) {
contextInfo.textContent = `Item ${parentLoopState.editIndex + 1} - ${parentFields.join(" | ")}`;
} else {
contextInfo.textContent = `Item ${parentLoopState.editIndex + 1} (bearbeiten)`;
}
} else {
const contextInfo = contextHeader.createEl("div", {
cls: "context-info",
text: `Item ${parentLoopState.editIndex + 1} (bearbeiten)`,
});
contextInfo.style.fontSize = "0.85em";
contextInfo.style.color = "var(--text-normal)";
}
} else {
// New item in parent loop
const contextInfo = contextHeader.createEl("div", {
cls: "context-info",
text: "Neues Item (wird erstellt)",
});
contextInfo.style.fontSize = "0.85em";
contextInfo.style.color = "var(--text-muted)";
contextInfo.style.fontStyle = "italic";
}
// Navigation buttons for nested loop
const nestedNav = nestedRightPane.createEl("div", {
cls: "nested-loop-navigation",
}
}
// Title
containerEl.createEl("h2", {
text: nestedStep.label || "Verschachtelter Loop",
});
// Show editing indicator
if (nestedLoopState.editIndex !== null) {
const indicator = containerEl.createEl("div", {
cls: "loop-editing-indicator",
text: `✏️ Editing item ${nestedLoopState.editIndex + 1}`,
});
indicator.style.padding = "0.5em";
indicator.style.background = "var(--background-modifier-border-hover)";
indicator.style.borderRadius = "4px";
indicator.style.marginBottom = "1em";
}
// Full-width 2-pane container
const paneContainer = containerEl.createEl("div", {
cls: "loop-pane-container",
});
paneContainer.style.display = "flex";
paneContainer.style.gap = "1em";
paneContainer.style.width = "100%";
// Left pane: Items list (30% for fullscreen mode)
const leftPane = paneContainer.createEl("div", {
cls: "loop-items-pane",
});
leftPane.style.width = "30%";
leftPane.style.borderRight = "1px solid var(--background-modifier-border)";
leftPane.style.paddingRight = "1em";
leftPane.style.maxHeight = "70vh";
leftPane.style.overflowY = "auto";
const itemsTitle = leftPane.createEl("h3", {
text: "Einträge",
});
itemsTitle.style.marginBottom = "0.5em";
// Render items list (same as normal loop)
nestedLoopState.items.forEach((item, i) => {
const itemEl = leftPane.createEl("div", {
cls: "loop-item",
});
itemEl.style.padding = "0.75em";
itemEl.style.marginBottom = "0.5em";
itemEl.style.background = "var(--background-secondary)";
itemEl.style.borderRadius = "4px";
itemEl.style.cursor = "pointer";
// Extract first non-empty field value for display
let itemText = `Item ${i + 1}`;
if (typeof item === "object" && item !== null) {
const itemObj = item as Record<string, unknown>;
for (const [key, value] of Object.entries(itemObj)) {
if (value && typeof value === "string" && value.trim() !== "") {
itemText = value.trim();
break;
}
}
} else if (item) {
itemText = String(item);
}
itemEl.textContent = itemText.length > 50 ? itemText.substring(0, 50) + "..." : itemText;
// Action buttons (same as normal loop)
const buttonContainer = itemEl.createEl("div", {
cls: "loop-item-actions",
});
buttonContainer.style.display = "flex";
buttonContainer.style.gap = "0.25em";
buttonContainer.style.marginTop = "0.5em";
buttonContainer.style.justifyContent = "flex-end";
const editBtn = buttonContainer.createEl("button", { text: "✏️ Edit" });
editBtn.onclick = () => {
let newState = startEdit(nestedLoopState!, i);
newState = resetItemWizard(newState);
this.state.loopRuntimeStates.set(nestedLoopKey, newState);
this.renderStep();
};
const moveUpBtn = buttonContainer.createEl("button", { text: "↑" });
moveUpBtn.disabled = i === 0;
moveUpBtn.onclick = () => {
const newState = moveItemUp(nestedLoopState!, i);
this.state.loopRuntimeStates.set(nestedLoopKey, newState);
this.renderStep();
};
const moveDownBtn = buttonContainer.createEl("button", { text: "↓" });
moveDownBtn.disabled = i >= nestedLoopState.items.length - 1;
moveDownBtn.onclick = () => {
const newState = moveItemDown(nestedLoopState!, i);
this.state.loopRuntimeStates.set(nestedLoopKey, newState);
this.renderStep();
};
const deleteBtn = buttonContainer.createEl("button", { text: "🗑️" });
deleteBtn.onclick = () => {
const newState = deleteItem(nestedLoopState!, i);
this.state.loopRuntimeStates.set(nestedLoopKey, newState);
this.renderStep();
};
});
// Right pane: Editor (70% for fullscreen mode)
const rightPane = paneContainer.createEl("div", {
cls: "loop-editor-pane",
});
rightPane.style.width = "70%";
rightPane.style.flex = "1";
// Subwizard header
const itemTitle = rightPane.createEl("h3");
const itemTitleText = nestedLoopState.editIndex !== null
? `Item: ${nestedLoopState.editIndex + 1} (editing)`
: "Item: New";
const stepCounter = nestedStep.items.length > 0
? ` - Step ${nestedLoopState.activeItemStepIndex + 1}/${nestedStep.items.length}`
: "";
itemTitle.textContent = itemTitleText + stepCounter;
// Render active nested step
if (nestedStep.items.length > 0) {
const activeStepIndex = Math.min(nestedLoopState.activeItemStepIndex, nestedStep.items.length - 1);
const activeNestedStep = nestedStep.items[activeStepIndex];
if (activeNestedStep) {
const editorContainer = rightPane.createEl("div", {
cls: "loop-item-editor",
});
nestedNav.style.display = "flex";
nestedNav.style.gap = "0.5em";
nestedNav.style.marginTop = "1em";
nestedNav.style.justifyContent = "space-between";
// Left: Back/Next
const nestedNavLeft = nestedNav.createEl("div");
nestedNavLeft.style.display = "flex";
nestedNavLeft.style.gap = "0.5em";
const draftValue = nestedLoopState.draft[activeNestedStep.key];
this.renderLoopNestedStep(
activeNestedStep,
editorContainer,
nestedLoopKey,
draftValue,
(fieldId, value) => {
const currentState = this.state.loopRuntimeStates.get(nestedLoopKey);
if (currentState) {
const newState = setDraftField(currentState, fieldId, value);
this.state.loopRuntimeStates.set(nestedLoopKey, newState);
// Update parent draft
const lastDotIndex = nestedLoopKey.lastIndexOf(".");
if (lastDotIndex > 0) {
const parentLoopKey = nestedLoopKey.substring(0, lastDotIndex);
if (parentLoopKey) {
const parentState = this.state.loopRuntimeStates.get(parentLoopKey);
if (parentState && nestedStepKey) {
const updatedParentDraft = {
...parentState.draft,
[nestedStepKey]: newState.items,
};
const updatedParentState = setDraftField(parentState, nestedStepKey, newState.items);
this.state.loopRuntimeStates.set(parentLoopKey, updatedParentState);
}
}
}
}
}
);
const nestedBackBtn = nestedNavLeft.createEl("button", {
text: "← Zurück",
// Navigation buttons (same as normal loop)
const subwizardNav = rightPane.createEl("div", {
cls: "loop-subwizard-navigation",
});
nestedBackBtn.disabled = activeNestedStepIndex === 0;
nestedBackBtn.onclick = () => {
const currentNestedState = this.state.loopRuntimeStates.get(nestedLoopKey);
if (currentNestedState) {
const newState = itemPrevStep(currentNestedState);
subwizardNav.style.display = "flex";
subwizardNav.style.gap = "0.5em";
subwizardNav.style.marginTop = "1em";
subwizardNav.style.justifyContent = "space-between";
const itemNavLeft = subwizardNav.createEl("div");
itemNavLeft.style.display = "flex";
itemNavLeft.style.gap = "0.5em";
const itemBackBtn = itemNavLeft.createEl("button", { text: "← Item Back" });
itemBackBtn.disabled = activeStepIndex === 0;
itemBackBtn.onclick = () => {
const currentState = this.state.loopRuntimeStates.get(nestedLoopKey);
if (currentState) {
const newState = itemPrevStep(currentState);
this.state.loopRuntimeStates.set(nestedLoopKey, newState);
this.renderStep();
}
};
const nestedNextBtn = nestedNavLeft.createEl("button", {
text: "Weiter →",
});
nestedNextBtn.disabled = activeNestedStepIndex >= nestedStep.items.length - 1;
nestedNextBtn.onclick = () => {
const currentNestedState = this.state.loopRuntimeStates.get(nestedLoopKey);
if (currentNestedState) {
const newState = itemNextStep(currentNestedState, nestedStep.items.length);
const itemNextBtn = itemNavLeft.createEl("button", { text: "Item Next →" });
itemNextBtn.disabled = activeStepIndex >= nestedStep.items.length - 1;
itemNextBtn.onclick = () => {
const currentState = this.state.loopRuntimeStates.get(nestedLoopKey);
if (currentState) {
const newState = itemNextStep(currentState, nestedStep.items.length);
this.state.loopRuntimeStates.set(nestedLoopKey, newState);
this.renderStep();
}
};
// Right: Save/Clear
const nestedNavRight = nestedNav.createEl("div");
nestedNavRight.style.display = "flex";
nestedNavRight.style.gap = "0.5em";
const itemNavRight = subwizardNav.createEl("div");
itemNavRight.style.display = "flex";
itemNavRight.style.gap = "0.5em";
const nestedSaveBtn = nestedNavRight.createEl("button", {
text: nestedLoopState.editIndex !== null ? "Speichern" : "Hinzufügen",
const doneBtn = itemNavRight.createEl("button", {
text: nestedLoopState.editIndex !== null ? "Save Item" : "Done",
cls: "mod-cta",
});
nestedSaveBtn.onclick = () => {
const currentNestedState = this.state.loopRuntimeStates.get(nestedLoopKey);
if (currentNestedState && isDraftDirty(currentNestedState.draft)) {
const newState = commitDraft(currentNestedState);
doneBtn.onclick = () => {
const currentState = this.state.loopRuntimeStates.get(nestedLoopKey);
if (currentState && isDraftDirty(currentState.draft)) {
const newState = commitDraft(currentState);
this.state.loopRuntimeStates.set(nestedLoopKey, newState);
// Update parent draft
onFieldChange(nestedStep.key, newState.items);
// Re-render to show updated item list
const lastDotIndex = nestedLoopKey.lastIndexOf(".");
if (lastDotIndex > 0 && nestedStepKey) {
const parentLoopKey = nestedLoopKey.substring(0, lastDotIndex);
if (parentLoopKey) {
const parentState = this.state.loopRuntimeStates.get(parentLoopKey);
if (parentState) {
const updatedParentState = setDraftField(parentState, nestedStepKey, newState.items);
this.state.loopRuntimeStates.set(parentLoopKey, updatedParentState);
}
}
}
this.renderStep();
}
};
if (nestedLoopState.editIndex !== null || isDraftDirty(nestedLoopState.draft)) {
const nestedClearBtn = nestedNavRight.createEl("button", {
text: "Löschen",
});
nestedClearBtn.onclick = () => {
const currentNestedState = this.state.loopRuntimeStates.get(nestedLoopKey);
if (currentNestedState) {
const newState = clearDraft(currentNestedState);
const clearBtn = itemNavRight.createEl("button", { text: "Clear" });
clearBtn.onclick = () => {
const currentState = this.state.loopRuntimeStates.get(nestedLoopKey);
if (currentState) {
const newState = clearDraft(currentState);
this.state.loopRuntimeStates.set(nestedLoopKey, newState);
this.renderStep();
}