feat: property editor

This commit is contained in:
hypercross 2026-02-27 13:00:24 +08:00
parent 5638b6200b
commit a292624c08
7 changed files with 349 additions and 15 deletions

8
.idea/indexLayout.xml Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="UserContentModel">
<attachedFolders />
<explicitIncludes />
<explicitExcludes />
</component>
</project>

6
.idea/vcs.xml Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

View File

@ -0,0 +1,156 @@
import {Component, For, Show} from 'solid-js';
import type { FieldSchema } from './hooks/types';
export interface FieldEditorProps {
/** 字段 Schema 定义 */
field: FieldSchema;
/** 当前值 */
value: string | number | boolean;
/** 值变化回调 */
onChange: (value: string | number | boolean) => void;
/** 是否禁用 */
disabled?: boolean;
}
/**
*
*
*/
export const FieldEditor: Component<FieldEditorProps> = (props) => {
const handleChange = (e: Event) => {
const target = e.target as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
let value: string | number | boolean;
if (props.field.type === 'boolean') {
value = (target as HTMLInputElement).checked;
} else if (props.field.type === 'number') {
value = Number(target.value);
} else {
value = target.value;
}
props.onChange(value);
};
const label = props.field.label || props.field.key;
return (
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
{label}
<Show when={props.field.required}>
<span class="text-red-500 ml-1">*</span>
</Show>
</label>
{/* 字符串输入 */}
<Show when={props.field.type === 'string'}>
<input
type="text"
class="w-full border border-gray-300 rounded px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder={props.field.placeholder}
value={props.value as string}
onInput={handleChange}
disabled={props.disabled}
/>
</Show>
{/* 多行文本输入 */}
<Show when={props.field.type === 'text'}>
<textarea
class="w-full border border-gray-300 rounded px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder={props.field.placeholder}
rows={props.field.rows || 3}
value={props.value as string}
onInput={handleChange}
disabled={props.disabled}
/>
</Show>
{/* 数字输入 */}
<Show when={props.field.type === 'number'}>
<input
type="number"
class="w-full border border-gray-300 rounded px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder={props.field.placeholder}
min={props.field.min}
max={props.field.max}
value={props.value as number}
onInput={handleChange}
disabled={props.disabled}
/>
</Show>
{/* 布尔输入 */}
<Show when={props.field.type === 'boolean'}>
<div class="flex items-center">
<input
type="checkbox"
class="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
checked={props.value as boolean}
onChange={handleChange}
disabled={props.disabled}
/>
<Show when={props.field.description}>
<span class="ml-2 text-sm text-gray-600">{props.field.description}</span>
</Show>
</div>
</Show>
{/* 选择输入 */}
<Show when={props.field.type === 'select'}>
<select
class="w-full border border-gray-300 rounded px-2 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={props.value as string}
onChange={handleChange}
disabled={props.disabled}
>
<Show when={props.field.placeholder}>
<option value="">{props.field.placeholder}</option>
</Show>
<For each={props.field.options}>
{(option) => (
<option value={option.value}>{option.label}</option>
)}
</For>
</select>
</Show>
<Show when={props.field.description && props.field.type !== 'boolean'}>
<p class="mt-1 text-xs text-gray-500">{props.field.description}</p>
</Show>
</div>
);
};
/**
*
* Schema
*/
export interface PropertyFormProps<T extends Record<string, string | number | boolean>> {
/** Schema 定义 */
schema: import('./hooks/types').Schema;
/** 当前值 */
values: T;
/** 值变化回调 */
onChange: (key: string, value: string | number | boolean) => void;
/** 是否禁用 */
disabled?: boolean;
}
export const PropertyForm: Component<PropertyFormProps<any>> = (props) => {
return (
<div class="space-y-3">
<For each={props.schema.fields}>
{(field) => (
<FieldEditor
field={field}
value={props.values[field.key]}
onChange={(value) => props.onChange(field.key, value)}
disabled={props.disabled}
/>
)}
</For>
</div>
);
};

View File

@ -1,5 +1,7 @@
import { Show, For } from 'solid-js'; import { Show, For } from 'solid-js';
import type { CardData, LayerConfig } from './types'; import type { CardData, LayerConfig } from './types';
import { FieldEditor, PropertyForm } from './FieldEditor';
import type { FieldSchema } from './hooks/types';
export interface DataEditorPanelProps { export interface DataEditorPanelProps {
cards: CardData[]; cards: CardData[];
@ -23,27 +25,40 @@ export interface PropertiesEditorPanelProps {
onCopyCode: () => void; onCopyCode: () => void;
} }
/**
* CardData Schema
*/
function createCardDataSchema(keys: string[]): FieldSchema[] {
return keys.map((key) => ({
key,
label: key.charAt(0).toUpperCase() + key.slice(1),
type: 'text',
rows: 3
}));
}
/** /**
* CSV * CSV
* 使 FieldEditor
*/ */
export function DataEditorPanel(props: DataEditorPanelProps) { export function DataEditorPanel(props: DataEditorPanelProps) {
const currentCard = () => props.cards[props.activeTab] || {};
const fieldKeys = () => Object.keys(currentCard());
const schema = () => ({ fields: createCardDataSchema(fieldKeys()) });
const handleChange = (key: string, value: string | number | boolean) => {
props.updateCardData(key, String(value));
};
return ( return (
<div class="w-64 flex-shrink-0"> <div class="w-64 flex-shrink-0">
<h3 class="font-bold mb-2"></h3> <h3 class="font-bold mb-2"></h3>
<div class="space-y-2 max-h-96 overflow-y-auto"> <div class="space-y-2 max-h-96 overflow-y-auto">
<For each={Object.keys(props.cards[props.activeTab] || {})}> <PropertyForm
{(key) => ( schema={schema()}
<div> values={currentCard()}
<label class="block text-sm font-medium text-gray-700">{key}</label> onChange={handleChange}
<textarea />
class="w-full border border-gray-300 rounded px-2 py-1 text-sm"
rows={3}
value={props.cards[props.activeTab]?.[key] || ''}
onInput={(e) => props.updateCardData(key, e.target.value)}
/>
</div>
)}
</For>
</div> </div>
</div> </div>
); );

View File

@ -0,0 +1,58 @@
/**
* Schema
*/
export type FieldType = 'string' | 'number' | 'boolean' | 'text' | 'select';
export interface FieldSchema {
/** 字段名 */
key: string;
/** 字段类型 */
type: FieldType;
/** 显示标签 */
label?: string;
/** 占位符文本 */
placeholder?: string;
/** 描述/提示文本 */
description?: string;
/** 是否必需 */
required?: boolean;
/** 最小值(用于 number 类型) */
min?: number;
/** 最大值(用于 number 类型) */
max?: number;
/** 选项(用于 select 类型) */
options?: { label: string; value: string }[];
/** 行数(用于 text 类型) */
rows?: number;
/** 默认值 */
default?: string | number | boolean;
}
export interface Schema {
/** Schema 名称 */
name?: string;
/** 字段定义列表 */
fields: FieldSchema[];
}
/**
*
*/
export type PropertyValues = Record<string, string | number | boolean>;
/**
* usePropertyEditor hook
*/
export interface UsePropertyEditorReturn<T extends PropertyValues> {
/** 获取属性值 */
get: <K extends keyof T>(key: K) => T[K];
/** 设置属性值 */
set: <K extends keyof T>(key: K, value: T[K]) => void;
/** 获取所有属性值 */
getAll: () => T;
/** 设置所有属性值 */
setAll: (values: T) => void;
/** 重置为默认值 */
reset: () => void;
}

View File

@ -0,0 +1,91 @@
import { createSignal, createMemo, For } from 'solid-js';
import type { Schema, PropertyValues, UsePropertyEditorReturn, FieldSchema } from './types';
/**
* Hook
*
* @param schema - Schema
* @param initialValues -
* @returns
*/
export function usePropertyEditor<T extends PropertyValues>(
schema: Schema,
initialValues: T
): UsePropertyEditorReturn<T> & {
/** Schema 定义 */
schema: Schema;
/** 字段信号 Map */
fieldSignals: Map<keyof T, ReturnType<typeof createSignal<string | number | boolean>>>;
} {
// 为每个字段创建独立的 signal
const fieldSignals = new Map<keyof T, ReturnType<typeof createSignal<string | number | boolean>>>();
for (const field of schema.fields) {
const key = field.key as keyof T;
const initialValue = initialValues[key] ?? field.default ?? '';
fieldSignals.set(key, createSignal(initialValue as any));
}
const get = <K extends keyof T>(key: K): T[K] => {
const signal = fieldSignals.get(key);
if (!signal) {
throw new Error(`Field "${String(key)}" not found in schema`);
}
return signal[0]() as T[K];
};
const set = <K extends keyof T>(key: K, value: T[K]) => {
const signal = fieldSignals.get(key);
if (!signal) {
throw new Error(`Field "${String(key)}" not found in schema`);
}
signal[1](value as any);
};
const getAll = (): T => {
const result = {} as T;
for (const field of schema.fields) {
const key = field.key as keyof T;
result[key] = get(key);
}
return result;
};
const setAll = (values: T) => {
for (const field of schema.fields) {
const key = field.key as keyof T;
if (key in values) {
set(key, values[key]);
}
}
};
const reset = () => {
for (const field of schema.fields) {
const key = field.key as keyof T;
const defaultValue = field.default ?? '';
set(key, defaultValue as T[keyof T]);
}
};
return {
schema,
fieldSignals,
get,
set,
getAll,
setAll,
reset
};
}
/**
* Schema
*/
export function createDefaultValues<T extends PropertyValues>(schema: Schema): T {
const result = {} as T;
for (const field of schema.fields) {
result[field.key as keyof T] = (field.default ?? '') as T[keyof T];
}
return result;
}

View File

@ -1,7 +1,7 @@
import { customElement, noShadowDOM } from 'solid-element'; import { customElement, noShadowDOM } from 'solid-element';
import { createSignal, For, Show, createEffect, createMemo, createResource } from 'solid-js'; import { createSignal, For, Show, createEffect, createMemo, createResource } from 'solid-js';
import { marked } from '../markdown'; import { marked } from '../markdown';
import { resolvePath } from './utils'; import { resolvePath } from './utils/path';
import { loadCSV } from './utils/csv-loader'; import { loadCSV } from './utils/csv-loader';
export interface TableProps { export interface TableProps {
@ -45,7 +45,7 @@ customElement('md-table', { roll: false, remix: false }, (props, { element }) =>
createEffect(() => { createEffect(() => {
const data = csvData(); const data = csvData();
if (data) { if (data) {
setRows(data); setRows(data as any[]);
} }
}); });