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

View File

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