import { App, TFolder } from 'obsidian'; import { FormField } from '../types'; // --------------------------------------------------------------------------- // Public types // --------------------------------------------------------------------------- export type FieldValue = string | number | boolean | string[]; export interface RenderedField { getValue: () => FieldValue; setValue: (value: FieldValue) => void; setError: (message: string | null) => void; } // --------------------------------------------------------------------------- // Wrapper helper // --------------------------------------------------------------------------- interface FieldWrapper { wrapper: HTMLDivElement; controlEl: HTMLDivElement; errorEl: HTMLDivElement; } function createFieldWrapper( container: HTMLElement, field: FormField, ): FieldWrapper { const wrapper = container.createDiv({ cls: 'ff-field' }); const labelEl = wrapper.createEl('label', { cls: 'ff-field-label', text: field.label, }); if (field.required) { labelEl.createSpan({ cls: 'ff-field-required', text: ' *' }); } const controlEl = wrapper.createDiv({ cls: 'ff-field-control' }); const errorEl = wrapper.createDiv({ cls: 'ff-field-error' }); errorEl.style.display = 'none'; return { wrapper, controlEl, errorEl }; } function makeErrorAccessor( wrapper: HTMLDivElement, errorEl: HTMLDivElement, ): (message: string | null) => void { return (message: string | null) => { if (message) { errorEl.textContent = message; errorEl.style.display = ''; wrapper.addClass('ff-field--error'); } else { errorEl.textContent = ''; errorEl.style.display = 'none'; wrapper.removeClass('ff-field--error'); } }; } // --------------------------------------------------------------------------- // Per-type renderers // --------------------------------------------------------------------------- function renderText( controlEl: HTMLDivElement, field: FormField, initialValue?: FieldValue, ): { getValue: () => FieldValue; setValue: (v: FieldValue) => void } { const input = controlEl.createEl('input', { cls: 'ff-input', type: 'text', placeholder: field.placeholder ?? '', }); if (initialValue !== undefined) input.value = String(initialValue); return { getValue: () => input.value, setValue: (v) => { input.value = String(v); }, }; } function renderTextarea( controlEl: HTMLDivElement, field: FormField, initialValue?: FieldValue, ): { getValue: () => FieldValue; setValue: (v: FieldValue) => void } { const textarea = controlEl.createEl('textarea', { cls: 'ff-textarea', placeholder: field.placeholder ?? '', }); textarea.rows = 4; if (initialValue !== undefined) textarea.value = String(initialValue); return { getValue: () => textarea.value, setValue: (v) => { textarea.value = String(v); }, }; } function renderNumber( controlEl: HTMLDivElement, field: FormField, initialValue?: FieldValue, ): { getValue: () => FieldValue; setValue: (v: FieldValue) => void } { const input = controlEl.createEl('input', { cls: 'ff-input', type: 'number', placeholder: field.placeholder ?? '', }); if (initialValue !== undefined) input.value = String(initialValue); return { getValue: () => { const v = input.value; return v === '' ? '' : Number(v); }, setValue: (v) => { input.value = String(v); }, }; } function renderToggle( controlEl: HTMLDivElement, _field: FormField, initialValue?: FieldValue, ): { getValue: () => FieldValue; setValue: (v: FieldValue) => void } { let enabled = initialValue === true || initialValue === 'true'; const toggle = controlEl.createDiv({ cls: 'checkbox-container' }); if (enabled) toggle.addClass('is-enabled'); toggle.setAttribute('tabindex', '0'); toggle.addEventListener('keydown', (e: KeyboardEvent) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); enabled = !enabled; toggle.toggleClass('is-enabled', enabled); } }); toggle.addEventListener('click', () => { enabled = !enabled; toggle.toggleClass('is-enabled', enabled); }); return { getValue: () => enabled, setValue: (v) => { enabled = v === true || v === 'true'; toggle.toggleClass('is-enabled', enabled); }, }; } function renderDate( controlEl: HTMLDivElement, field: FormField, initialValue?: FieldValue, ): { getValue: () => FieldValue; setValue: (v: FieldValue) => void } { const input = controlEl.createEl('input', { cls: 'ff-input', type: 'date', placeholder: field.placeholder ?? '', }); const today = new Date().toISOString().slice(0, 10); input.value = initialValue !== undefined ? String(initialValue) : today; return { getValue: () => input.value, setValue: (v) => { input.value = String(v); }, }; } function renderDropdown( controlEl: HTMLDivElement, field: FormField, initialValue?: FieldValue, ): { getValue: () => FieldValue; setValue: (v: FieldValue) => void } { const select = controlEl.createEl('select', { cls: 'dropdown ff-dropdown' }); // Empty first option select.createEl('option', { value: '', text: '' }); for (const opt of field.options ?? []) { select.createEl('option', { value: opt, text: opt }); } if (initialValue !== undefined) select.value = String(initialValue); return { getValue: () => select.value, setValue: (v) => { select.value = String(v); }, }; } function renderTags( controlEl: HTMLDivElement, field: FormField, initialValue?: FieldValue, ): { getValue: () => FieldValue; setValue: (v: FieldValue) => void } { let tags: string[] = []; if (Array.isArray(initialValue)) { tags = [...initialValue]; } else if (typeof initialValue === 'string' && initialValue) { tags = initialValue.split(',').map((t) => t.trim()).filter(Boolean); } const container = controlEl.createDiv({ cls: 'ff-tags-container' }); const chipsEl = container.createDiv({ cls: 'ff-tags-chips' }); const input = container.createEl('input', { cls: 'ff-tags-input', type: 'text', placeholder: field.placeholder ?? 'Type and press Enter...', }); function renderChips(): void { chipsEl.empty(); for (const tag of tags) { const chip = chipsEl.createSpan({ cls: 'ff-tag-chip' }); chip.createSpan({ cls: 'ff-tag-text', text: tag }); const removeBtn = chip.createSpan({ cls: 'ff-tag-remove', text: '\u00D7' }); removeBtn.addEventListener('click', () => { tags = tags.filter((t) => t !== tag); renderChips(); }); } } function addTag(value: string): void { const trimmed = value.trim(); if (trimmed && !tags.includes(trimmed)) { tags.push(trimmed); renderChips(); } input.value = ''; } input.addEventListener('keydown', (e: KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault(); addTag(input.value); } // Backspace on empty input removes last tag if (e.key === 'Backspace' && input.value === '' && tags.length > 0) { tags.pop(); renderChips(); } }); renderChips(); return { getValue: () => [...tags], setValue: (v) => { if (Array.isArray(v)) { tags = [...v]; } else if (typeof v === 'string') { tags = v.split(',').map((t) => t.trim()).filter(Boolean); } else { tags = []; } renderChips(); }, }; } function renderSlider( controlEl: HTMLDivElement, field: FormField, initialValue?: FieldValue, ): { getValue: () => FieldValue; setValue: (v: FieldValue) => void } { const min = field.min ?? 0; const max = field.max ?? 100; const step = field.step ?? 1; const startVal = initialValue !== undefined ? Number(initialValue) : min; const wrapper = controlEl.createDiv({ cls: 'ff-slider-wrapper' }); const input = wrapper.createEl('input', { cls: 'ff-slider', type: 'range', }); input.min = String(min); input.max = String(max); input.step = String(step); input.value = String(startVal); const valueLabel = wrapper.createSpan({ cls: 'ff-slider-value', text: String(startVal), }); input.addEventListener('input', () => { valueLabel.textContent = input.value; }); return { getValue: () => Number(input.value), setValue: (v) => { input.value = String(v); valueLabel.textContent = String(v); }, }; } function renderColor( controlEl: HTMLDivElement, _field: FormField, initialValue?: FieldValue, ): { getValue: () => FieldValue; setValue: (v: FieldValue) => void } { const startVal = initialValue !== undefined ? String(initialValue) : '#000000'; const wrapper = controlEl.createDiv({ cls: 'ff-color-wrapper' }); const input = wrapper.createEl('input', { cls: 'ff-color-input', type: 'color', }); input.value = startVal; const hexLabel = wrapper.createSpan({ cls: 'ff-color-hex', text: startVal, }); input.addEventListener('input', () => { hexLabel.textContent = input.value; }); return { getValue: () => input.value, setValue: (v) => { input.value = String(v); hexLabel.textContent = String(v); }, }; } function renderTime( controlEl: HTMLDivElement, field: FormField, initialValue?: FieldValue, ): { getValue: () => FieldValue; setValue: (v: FieldValue) => void } { const input = controlEl.createEl('input', { cls: 'ff-input', type: 'time', placeholder: field.placeholder ?? '', }); if (initialValue !== undefined) { input.value = String(initialValue); } return { getValue: () => input.value, setValue: (v) => { input.value = String(v); }, }; } function renderNoteLink( app: App, controlEl: HTMLDivElement, field: FormField, initialValue?: FieldValue, ): { getValue: () => FieldValue; setValue: (v: FieldValue) => void } { const wrapper = controlEl.createDiv({ cls: 'ff-suggest-wrapper' }); const input = wrapper.createEl('input', { cls: 'ff-input', type: 'text', placeholder: field.placeholder ?? 'Search notes...', }); const suggestList = wrapper.createDiv({ cls: 'ff-suggest-list' }); suggestList.style.display = 'none'; if (initialValue !== undefined) input.value = String(initialValue); function getFiles(): string[] { let files = app.vault.getMarkdownFiles(); if (field.folder) { const prefix = field.folder.endsWith('/') ? field.folder : field.folder + '/'; files = files.filter((f) => f.path.startsWith(prefix)); } return files.map((f) => f.path); } function showSuggestions(): void { const query = input.value.toLowerCase(); const matches = getFiles() .filter((p) => p.toLowerCase().includes(query)) .slice(0, 10); suggestList.empty(); if (matches.length === 0) { suggestList.style.display = 'none'; return; } for (const match of matches) { const item = suggestList.createDiv({ cls: 'ff-suggest-item' }); item.textContent = match; item.addEventListener('mousedown', (e: MouseEvent) => { e.preventDefault(); input.value = match; suggestList.style.display = 'none'; }); } suggestList.style.display = ''; } input.addEventListener('input', showSuggestions); input.addEventListener('focus', showSuggestions); input.addEventListener('blur', () => { setTimeout(() => { suggestList.style.display = 'none'; }, 200); }); return { getValue: () => input.value, setValue: (v) => { input.value = String(v); }, }; } function renderFolderPicker( app: App, controlEl: HTMLDivElement, field: FormField, initialValue?: FieldValue, ): { getValue: () => FieldValue; setValue: (v: FieldValue) => void } { const wrapper = controlEl.createDiv({ cls: 'ff-suggest-wrapper' }); const input = wrapper.createEl('input', { cls: 'ff-input', type: 'text', placeholder: field.placeholder ?? 'Search folders...', }); const suggestList = wrapper.createDiv({ cls: 'ff-suggest-list' }); suggestList.style.display = 'none'; if (initialValue !== undefined) input.value = String(initialValue); function getFolders(): string[] { return app.vault .getAllLoadedFiles() .filter((f): f is TFolder => f instanceof TFolder) .map((f) => f.path); } function showSuggestions(): void { const query = input.value.toLowerCase(); const matches = getFolders() .filter((p) => p.toLowerCase().includes(query)) .slice(0, 10); suggestList.empty(); if (matches.length === 0) { suggestList.style.display = 'none'; return; } for (const match of matches) { const item = suggestList.createDiv({ cls: 'ff-suggest-item' }); item.textContent = match; item.addEventListener('mousedown', (e: MouseEvent) => { e.preventDefault(); input.value = match; suggestList.style.display = 'none'; }); } suggestList.style.display = ''; } input.addEventListener('input', showSuggestions); input.addEventListener('focus', showSuggestions); input.addEventListener('blur', () => { setTimeout(() => { suggestList.style.display = 'none'; }, 200); }); return { getValue: () => input.value, setValue: (v) => { input.value = String(v); }, }; } function renderRating( controlEl: HTMLDivElement, _field: FormField, initialValue?: FieldValue, ): { getValue: () => FieldValue; setValue: (v: FieldValue) => void } { let rating = initialValue !== undefined && initialValue !== '' ? Number(initialValue) : 0; const ratingEl = controlEl.createDiv({ cls: 'ff-rating' }); const stars: HTMLSpanElement[] = []; function updateStars(): void { for (let i = 0; i < 5; i++) { stars[i].textContent = i < rating ? '\u2605' : '\u2606'; stars[i].toggleClass('ff-star-active', i < rating); } } for (let i = 0; i < 5; i++) { const star = ratingEl.createSpan({ cls: 'ff-star' }); star.textContent = '\u2606'; star.addEventListener('click', () => { rating = i + 1; updateStars(); }); star.addEventListener('mouseenter', () => { for (let j = 0; j < 5; j++) { stars[j].textContent = j <= i ? '\u2605' : '\u2606'; stars[j].toggleClass('ff-star-hover', j <= i); } }); stars.push(star); } ratingEl.addEventListener('mouseleave', () => { for (let j = 0; j < 5; j++) { stars[j].removeClass('ff-star-hover'); } updateStars(); }); ratingEl.setAttribute('tabindex', '0'); ratingEl.addEventListener('keydown', (e: KeyboardEvent) => { if (e.key === 'ArrowRight' || e.key === 'ArrowUp') { e.preventDefault(); rating = Math.min(5, rating + 1); updateStars(); } else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') { e.preventDefault(); rating = Math.max(1, rating - 1); updateStars(); } }); updateStars(); return { getValue: () => rating, setValue: (v) => { rating = Number(v) || 0; updateStars(); }, }; } // --------------------------------------------------------------------------- // Main entry point // --------------------------------------------------------------------------- export function renderField( app: App, container: HTMLElement, field: FormField, initialValue?: FieldValue, ): RenderedField { const { wrapper, controlEl, errorEl } = createFieldWrapper(container, field); const setError = makeErrorAccessor(wrapper, errorEl); let accessor: { getValue: () => FieldValue; setValue: (v: FieldValue) => void }; switch (field.type) { case 'text': accessor = renderText(controlEl, field, initialValue); break; case 'textarea': accessor = renderTextarea(controlEl, field, initialValue); break; case 'number': accessor = renderNumber(controlEl, field, initialValue); break; case 'toggle': accessor = renderToggle(controlEl, field, initialValue); break; case 'date': accessor = renderDate(controlEl, field, initialValue); break; case 'dropdown': accessor = renderDropdown(controlEl, field, initialValue); break; case 'tags': accessor = renderTags(controlEl, field, initialValue); break; case 'note-link': accessor = renderNoteLink(app, controlEl, field, initialValue); break; case 'folder-picker': accessor = renderFolderPicker(app, controlEl, field, initialValue); break; case 'rating': accessor = renderRating(controlEl, field, initialValue); break; case 'slider': accessor = renderSlider(controlEl, field, initialValue); break; case 'color': accessor = renderColor(controlEl, field, initialValue); break; case 'time': accessor = renderTime(controlEl, field, initialValue); break; default: accessor = renderText(controlEl, field, initialValue); break; } return { getValue: accessor.getValue, setValue: accessor.setValue, setError, }; }