632 lines
17 KiB
TypeScript
632 lines
17 KiB
TypeScript
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,
|
|
};
|
|
}
|