@c2-markdown-ui/svelte 0.0.0-stage → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +91 -2
- package/package.json +37 -5
- package/src/lib/MarkdownUI.svelte +60 -0
- package/src/lib/Navbar.svelte +144 -0
- package/src/lib/WidgetEvents.svelte +139 -0
- package/src/lib/index.ts +2 -0
- package/src/lib/widgets/Button.svelte +30 -0
- package/src/lib/widgets/ButtonGroup.svelte +37 -0
- package/src/lib/widgets/Cascader.svelte +486 -0
- package/src/lib/widgets/Chart.svelte +278 -0
- package/src/lib/widgets/DatePicker.svelte +609 -0
- package/src/lib/widgets/Form.svelte +571 -0
- package/src/lib/widgets/Incomplete.svelte +72 -0
- package/src/lib/widgets/MultipleChoiceQuestion.svelte +65 -0
- package/src/lib/widgets/NumberInput.svelte +149 -0
- package/src/lib/widgets/Quiz.svelte +301 -0
- package/src/lib/widgets/Radio.svelte +180 -0
- package/src/lib/widgets/Row.svelte +140 -0
- package/src/lib/widgets/Select.svelte +276 -0
- package/src/lib/widgets/SelectMulti.svelte +130 -0
- package/src/lib/widgets/ShortAnswerQuestion.svelte +57 -0
- package/src/lib/widgets/Slider.svelte +46 -0
- package/src/lib/widgets/Switch.svelte +60 -0
- package/src/lib/widgets/Text.svelte +58 -0
- package/src/lib/widgets/TextInput.svelte +58 -0
- package/src/lib/widgets/TreeSelect.svelte +924 -0
- package/src/lib/widgets/Widget.svelte +90 -0
- package/src/lib/widgets/chartRemoteData.ts +84 -0
- package/src/lib/widgets/index.ts +23 -0
- package/src/lib/widgets/remoteChoices.ts +90 -0
- package/src/lib/widgets.css +1754 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
<svelte:options
|
|
2
|
+
customElement={{
|
|
3
|
+
tag: 'markdown-ui-widget',
|
|
4
|
+
shadow: 'none', // use 'none' if you want light-DOM
|
|
5
|
+
props: {
|
|
6
|
+
id: { // the prop you asked for
|
|
7
|
+
type: 'String', // treat incoming attribute as plain text
|
|
8
|
+
attribute: 'id', // <markdown-ui-widget content="…">
|
|
9
|
+
reflect: true // update the attribute if we change it
|
|
10
|
+
},
|
|
11
|
+
content: { // the prop you asked for
|
|
12
|
+
type: 'String', // treat incoming attribute as plain text
|
|
13
|
+
attribute: 'content', // <markdown-ui-widget content="…">
|
|
14
|
+
reflect: true // update the attribute if we change it
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}}
|
|
18
|
+
/>
|
|
19
|
+
|
|
20
|
+
<script lang="ts">
|
|
21
|
+
import * as widgets from './index.js';
|
|
22
|
+
|
|
23
|
+
let { id, content } = $props();
|
|
24
|
+
|
|
25
|
+
// Type locking: track the first valid widget type we see to prevent flickering
|
|
26
|
+
let lockedType: string | null = null;
|
|
27
|
+
|
|
28
|
+
// Parse content and apply type locking
|
|
29
|
+
function parseContent(content: string): any {
|
|
30
|
+
try {
|
|
31
|
+
const decoded = JSON.parse(decodeURIComponent(content));
|
|
32
|
+
|
|
33
|
+
// If we have a valid type and no locked type yet, lock it
|
|
34
|
+
if (decoded.type && decoded.type !== 'incomplete' && !lockedType) {
|
|
35
|
+
lockedType = decoded.type;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// If we have a locked type but the new parse shows incomplete,
|
|
39
|
+
// keep using the locked type (prevents flickering)
|
|
40
|
+
if (lockedType && decoded.type === 'incomplete') {
|
|
41
|
+
return { ...decoded, type: lockedType, _streaming: true };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return decoded;
|
|
45
|
+
} catch (e) {
|
|
46
|
+
// If parsing fails but we have a locked type, return a streaming placeholder
|
|
47
|
+
if (lockedType) {
|
|
48
|
+
return { type: lockedType, _streaming: true, _pending: true };
|
|
49
|
+
}
|
|
50
|
+
return { type: "incomplete" };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Use $derived to reactively parse content
|
|
55
|
+
let parsed: any = $derived(parseContent(content));
|
|
56
|
+
|
|
57
|
+
let Cmp = $derived(widgets[parsed.type as keyof typeof widgets]);
|
|
58
|
+
|
|
59
|
+
function dispatch(detail: any) {
|
|
60
|
+
try {
|
|
61
|
+
const host = $host();
|
|
62
|
+
if (host) {
|
|
63
|
+
host.dispatchEvent(
|
|
64
|
+
new CustomEvent('widget-event', {
|
|
65
|
+
detail: detail,
|
|
66
|
+
bubbles: true,
|
|
67
|
+
composed: true // let it cross the shadow-root boundary
|
|
68
|
+
})
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
} catch (error) {
|
|
72
|
+
console.warn('Failed to dispatch widget event:', error);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
</script>
|
|
76
|
+
|
|
77
|
+
<div class="widget" class:streaming={parsed._streaming}>
|
|
78
|
+
{#if Cmp}
|
|
79
|
+
<Cmp {...parsed} onchange={(value: any) => dispatch({id: id, value: value})}/>
|
|
80
|
+
{:else}
|
|
81
|
+
<span style="color:red">Unknown widget "{parsed.type}"</span>
|
|
82
|
+
{/if}
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<style>
|
|
86
|
+
.widget.streaming {
|
|
87
|
+
/* Subtle visual indicator that content is still streaming */
|
|
88
|
+
opacity: 0.95;
|
|
89
|
+
}
|
|
90
|
+
</style>
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 图表远端数据源解析:
|
|
3
|
+
* - CSV 文本(与 DSL 内嵌 CSV 同格式:首行 header「标签列,系列1,系列2...」,其后数据行)
|
|
4
|
+
* - JSON:支持 { labels, datasets:[{label,data}] } 及常见包装({data|result} 内嵌)
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface ChartDataset {
|
|
8
|
+
label: string;
|
|
9
|
+
data: number[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface RemoteChartData {
|
|
13
|
+
labels: string[];
|
|
14
|
+
datasets: ChartDataset[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** 解析 CSV 文本为图表数据(header 至少 2 列,数据列须为数字) */
|
|
18
|
+
export function parseChartCsv(text: string): RemoteChartData | null {
|
|
19
|
+
const lines = text
|
|
20
|
+
.split('\n')
|
|
21
|
+
.map(l => l.trim())
|
|
22
|
+
.filter(l => l.length > 0);
|
|
23
|
+
if (lines.length === 0) return null;
|
|
24
|
+
|
|
25
|
+
const headers = lines[0].split(',').map(h => h.trim());
|
|
26
|
+
if (headers.length < 2) return null;
|
|
27
|
+
|
|
28
|
+
const labels: string[] = [];
|
|
29
|
+
const datasets: ChartDataset[] = headers.slice(1).map(label => ({ label, data: [] }));
|
|
30
|
+
|
|
31
|
+
for (let i = 1; i < lines.length; i++) {
|
|
32
|
+
const values = lines[i].split(',').map(v => v.trim());
|
|
33
|
+
if (values.length !== headers.length) return null;
|
|
34
|
+
labels.push(values[0]);
|
|
35
|
+
for (let j = 1; j < values.length; j++) {
|
|
36
|
+
const num = parseFloat(values[j]);
|
|
37
|
+
if (isNaN(num)) return null;
|
|
38
|
+
datasets[j - 1].data.push(num);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return { labels, datasets };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 归一化 JSON 响应为图表数据(labels + datasets) */
|
|
45
|
+
export function normalizeRemoteChartData(json: any): RemoteChartData | null {
|
|
46
|
+
// 解开常见包装:{data|result|chart} 内嵌
|
|
47
|
+
let payload = json;
|
|
48
|
+
for (const key of ['data', 'result', 'chart']) {
|
|
49
|
+
if (payload && typeof payload === 'object' && !Array.isArray(payload)
|
|
50
|
+
&& payload[key] && typeof payload[key] === 'object') {
|
|
51
|
+
payload = payload[key];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!payload || typeof payload !== 'object') return null;
|
|
56
|
+
|
|
57
|
+
// 直接是 { labels, datasets } 结构
|
|
58
|
+
let labels: any = payload.labels;
|
|
59
|
+
let datasets: any = payload.datasets;
|
|
60
|
+
|
|
61
|
+
// 数组形式:[{ label, value }] → 单系列
|
|
62
|
+
if (Array.isArray(payload) && payload.length > 0 && payload[0]?.label !== undefined && payload[0]?.value !== undefined) {
|
|
63
|
+
labels = payload.map((it: any) => it.label);
|
|
64
|
+
datasets = [{ label: 'Series', data: payload.map((it: any) => Number(it.value)) }];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!Array.isArray(labels) || !Array.isArray(datasets) || labels.length === 0 || datasets.length === 0) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const normLabels = labels.map((l: any) => String(l));
|
|
72
|
+
const normDatasets: ChartDataset[] = [];
|
|
73
|
+
for (const ds of datasets) {
|
|
74
|
+
if (!ds || !Array.isArray(ds.data)) continue;
|
|
75
|
+
const data = ds.data.map((v: any) => {
|
|
76
|
+
const num = Number(v);
|
|
77
|
+
return isNaN(num) ? 0 : num;
|
|
78
|
+
});
|
|
79
|
+
normDatasets.push({ label: String(ds.label ?? 'Series'), data });
|
|
80
|
+
}
|
|
81
|
+
if (normDatasets.length === 0) return null;
|
|
82
|
+
|
|
83
|
+
return { labels: normLabels, datasets: normDatasets };
|
|
84
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export { default as slider } from './Slider.svelte';
|
|
2
|
+
export { default as "date-picker" } from './DatePicker.svelte';
|
|
3
|
+
export { default as button } from './Button.svelte';
|
|
4
|
+
export { default as "button-group" } from './ButtonGroup.svelte';
|
|
5
|
+
export { default as select } from './Select.svelte';
|
|
6
|
+
export { default as "select-multi" } from './SelectMulti.svelte';
|
|
7
|
+
export { default as "radio-group" } from './Radio.svelte';
|
|
8
|
+
export { default as "cascade" } from './Cascader.svelte';
|
|
9
|
+
export { default as "tree-select" } from './TreeSelect.svelte';
|
|
10
|
+
export { default as switch } from './Switch.svelte';
|
|
11
|
+
export { default as "text-input" } from './TextInput.svelte';
|
|
12
|
+
export { default as "number-input" } from './NumberInput.svelte';
|
|
13
|
+
export { default as text } from './Text.svelte';
|
|
14
|
+
export { default as form } from './Form.svelte';
|
|
15
|
+
export { default as row } from './Row.svelte';
|
|
16
|
+
export { default as "chart-line" } from './Chart.svelte';
|
|
17
|
+
export { default as "chart-bar" } from './Chart.svelte';
|
|
18
|
+
export { default as "chart-pie" } from './Chart.svelte';
|
|
19
|
+
export { default as "chart-scatter" } from './Chart.svelte';
|
|
20
|
+
export { default as incomplete } from './Incomplete.svelte';
|
|
21
|
+
export { default as "multiple-choice-question" } from './MultipleChoiceQuestion.svelte';
|
|
22
|
+
export { default as "short-answer-question" } from './ShortAnswerQuestion.svelte';
|
|
23
|
+
export { default as "quiz" } from './Quiz.svelte';
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/** 远端选项:value 是表单实际取值,label 是展示文案 */
|
|
2
|
+
export interface RemoteChoice {
|
|
3
|
+
value: string;
|
|
4
|
+
label: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 兼容常见远端响应格式并归一化为 {value,label}:
|
|
9
|
+
* - ["测试"] → value=label="测试"
|
|
10
|
+
* - [{key:"test",label:"测试"}] → value="test"(表单取值),label="测试"(展示)
|
|
11
|
+
* - [{value:"test",label:"测试"}] → value="test",label="测试"
|
|
12
|
+
* - {data:[...]} / {options:[...]} / {choices:[...]} / {items:[...]} → 取内嵌数组
|
|
13
|
+
*
|
|
14
|
+
* 对象条目取值优先级:value > key > id > name;展示优先级:label > name > text > value。
|
|
15
|
+
*/
|
|
16
|
+
export function normalizeRemoteChoices(json: any): RemoteChoice[] {
|
|
17
|
+
let list = json;
|
|
18
|
+
if (!Array.isArray(json)) {
|
|
19
|
+
list = Array.isArray(json?.data) ? json.data
|
|
20
|
+
: Array.isArray(json?.options) ? json.options
|
|
21
|
+
: Array.isArray(json?.choices) ? json.choices
|
|
22
|
+
: Array.isArray(json?.items) ? json.items
|
|
23
|
+
: [];
|
|
24
|
+
}
|
|
25
|
+
return list
|
|
26
|
+
.map((item: any): RemoteChoice | null => {
|
|
27
|
+
if (typeof item === 'string' || typeof item === 'number') {
|
|
28
|
+
const s = String(item);
|
|
29
|
+
return s ? { value: s, label: s } : null;
|
|
30
|
+
}
|
|
31
|
+
if (item && typeof item === 'object') {
|
|
32
|
+
const value = item.value ?? item.key ?? item.id ?? item.name;
|
|
33
|
+
if (value === undefined || value === null || value === '') return null;
|
|
34
|
+
const label = item.label ?? item.name ?? item.text ?? String(value);
|
|
35
|
+
return { value: String(value), label: String(label) };
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
})
|
|
39
|
+
.filter((c: RemoteChoice | null): c is RemoteChoice => c !== null);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 把选项集合解析为取值数组:条目本身已是 value 优先,字符串则用 label 匹配远端条目回查 value。
|
|
44
|
+
* 用于把本地 choices / default 值(可能写的是展示文案或 key:label 原文)映射到 value 体系。
|
|
45
|
+
*/
|
|
46
|
+
export function resolveChoiceValue(c: string, remote: RemoteChoice[]): string {
|
|
47
|
+
const parsed = parseChoice(c).value;
|
|
48
|
+
const hit = remote.find(r => r.value === c || r.label === c || r.value === parsed);
|
|
49
|
+
return hit ? hit.value : c;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 解析本地 choices 条目的 key:label 格式:
|
|
54
|
+
* - "bj:北京" → {value: "bj", label: "北京"}(value 是表单实际取值,label 是展示文案)
|
|
55
|
+
* - "北京" → {value: "北京", label: "北京"}(无冒号或不符合判定规则时视为纯文本)
|
|
56
|
+
* - "09:00" → {value: "09:00", label: "09:00"}(纯数字 key 视为时间/比例值,保持原样)
|
|
57
|
+
* - "http://x" → 原样(label 以 // 开头视为 URL)
|
|
58
|
+
*
|
|
59
|
+
* 判定规则:按首个冒号分割,仅当 key 非空且不含空白、非纯数字,
|
|
60
|
+
* 且 label 非空、不以 // 开头时才视为 key:label。
|
|
61
|
+
*/
|
|
62
|
+
export function parseChoice(c: string): RemoteChoice {
|
|
63
|
+
const s = String(c ?? '');
|
|
64
|
+
const idx = s.indexOf(':');
|
|
65
|
+
if (idx > 0) {
|
|
66
|
+
const key = s.slice(0, idx);
|
|
67
|
+
const label = s.slice(idx + 1);
|
|
68
|
+
if (key && label && !/\s/.test(key) && !/^\d+$/.test(key) && !label.startsWith('//')) {
|
|
69
|
+
return { value: key, label };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return { value: s, label: s };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 把本地 choices(string[] 或对象数组)解析为 {value,label} 数组。
|
|
76
|
+
* 对象条目:value > key 取值(表单实际取值),label 缺省回落到 value */
|
|
77
|
+
export function parseChoices(choices: string[] | Array<{ value?: string; key?: string; label?: string }> | undefined | null): RemoteChoice[] {
|
|
78
|
+
return (choices || []).map((c) => {
|
|
79
|
+
if (c !== null && typeof c === 'object') {
|
|
80
|
+
const value = (c as any).value ?? (c as any).key;
|
|
81
|
+
if (value === undefined || value === null || value === '') return null;
|
|
82
|
+
const valueStr = String(value);
|
|
83
|
+
const label = (c as any).label !== undefined && (c as any).label !== null && (c as any).label !== ''
|
|
84
|
+
? String((c as any).label)
|
|
85
|
+
: valueStr;
|
|
86
|
+
return { value: valueStr, label };
|
|
87
|
+
}
|
|
88
|
+
return parseChoice(c as string);
|
|
89
|
+
}).filter((c: RemoteChoice | null): c is RemoteChoice => c !== null);
|
|
90
|
+
}
|